> ## Documentation Index
> Fetch the complete documentation index at: https://mintlify.com/jaypopat/cf_ai_duet/llms.txt
> Use this file to discover all available pages before exploring further.

# Room Management

> Room lifecycle, client coordination, and workspace isolation

Rooms are the core abstraction for collaborative sessions in Duet. Each room represents an isolated workspace with a shared terminal and multiple connected clients.

## Room Manager

The `Manager` type in `/internal/room/manager.go` coordinates all rooms:

```go theme={null}
type Manager struct {
    rooms     map[string]*Room
    mu        sync.RWMutex
    workerURL string
    aiClient  *ai.Client
    logger    *log.Logger
}
```

### Thread-Safe Operations

The manager uses `sync.RWMutex` for concurrent access:

* **RLock/RUnlock**: Used for read operations (GetRoom, RoomCount, GetAIClient)
* **Lock/Unlock**: Used for write operations (CreateRoom, LeaveRoom)

This allows multiple clients to read room state simultaneously while preventing race conditions during modifications.

## Room Structure

Each room contains:

```go theme={null}
type Room struct {
    ID           string
    Description  string
    Host         string
    Connections  []*Client
    mu           sync.RWMutex
    Terminal     *terminal.Terminal
    AIMessages   []AIMessage
    WorkspaceDir string
}
```

### Key Fields

* **ID**: UUID generated at creation time
* **Description**: Optional user-provided description (used for workspace naming)
* **Host**: Username of the room creator
* **Connections**: List of connected clients
* **Terminal**: Shared terminal instance (nil until first client starts it)
* **AIMessages**: Conversation history for AI chat feature
* **WorkspaceDir**: Isolated filesystem directory for this room

## Client Structure

```go theme={null}
type Client struct {
    ID       string
    Username string
    IsHost   bool
    Events   chan RoomEvent
}
```

Each client has:

* **Unique ID**: UUID to distinguish multiple connections from same user
* **Display name**: From SSH session username
* **Host flag**: Only the creator has this set to true
* **Event channel**: Receives room events (join/leave/typing/ai\_sync)

## Room Lifecycle

### 1. Room Creation

```go theme={null}
func (m *Manager) CreateRoom(host, description string) (*Room, error) {
    m.mu.Lock()
    defer m.mu.Unlock()

    roomID := uuid.New().String()

    // Generate workspace name from description or random name
    var workspaceName string
    if description != "" {
        workspaceName = slugify(description)
    }
    if workspaceName == "" {
        workspaceName = fmt.Sprintf("%s-%s", 
            adjectives[rand.Intn(len(adjectives))], 
            nouns[rand.Intn(len(nouns))])
    }

    // Create isolated workspace directory
    baseDir := "/app/workspaces"
    if _, err := os.Stat(baseDir); os.IsNotExist(err) {
        baseDir = filepath.Join(os.TempDir(), "duet-workspaces")
    }
    workspaceDir := filepath.Join(baseDir, workspaceName)

    // Copy template if available
    cmd := exec.Command("cp", "-r", "/app/workspace-template/.", workspaceDir)
    if err := cmd.Run(); err != nil {
        // Fallback: create empty directory
        if err := os.MkdirAll(workspaceDir, 0755); err != nil {
            return nil, fmt.Errorf("failed to create workspace directory: %w", err)
        }
    }

    room := &Room{
        ID:           roomID,
        Description:  description,
        Host:         host,
        Connections:  make([]*Client, 0),
        WorkspaceDir: workspaceDir,
    }
    m.rooms[roomID] = room
    return room, nil
}
```

**Workspace Naming:**

* If description provided: `slugify("My Project")` → `my-project`
* Otherwise: random name like `cosmic-phoenix` or `brave-dragon`

**Workspace Template:**

The server attempts to copy `/app/workspace-template/` contents into each workspace, providing pre-configured files or tools. Falls back to an empty directory if template is unavailable.

### 2. Joining a Room

```go theme={null}
func (m *Manager) GetRoom(roomID string) (*Room, error) {
    m.mu.RLock()
    defer m.mu.RUnlock()

    room, exists := m.rooms[roomID]
    if !exists {
        return nil, ErrRoomNotFound
    }

    return room, nil
}
```

Clients join by providing the room ID. The manager returns the room reference if it exists.

### 3. Client Registration

In the UI model (`/internal/ui/model.go`):

```go theme={null}
func (m *Model) registerAsClient(r *room.Room, isHost bool) {
    m.eventChan = make(chan room.RoomEvent, 10)

    client := &room.Client{
        ID:       m.clientID,
        Username: m.username,
        IsHost:   isHost,
        Events:   m.eventChan,
    }
    r.AddClient(client)
}
```

The room broadcasts a join event to all existing clients:

```go theme={null}
func (r *Room) AddClient(client *Client) {
    r.mu.Lock()
    defer r.mu.Unlock()

    // Remove duplicate if reconnecting
    for i, c := range r.Connections {
        if c.ID == client.ID {
            if c.Events != nil {
                close(c.Events)
            }
            r.Connections = remove(r.Connections, i)
            break
        }
    }

    r.Connections = append(r.Connections, client)

    // Notify other clients
    for _, c := range r.Connections {
        if c.ID != client.ID && c.Events != nil {
            select {
            case c.Events <- RoomEvent{Type: "join", Username: client.Username}:
            default:
            }
        }
    }
}
```

**Non-Blocking Sends:**

The `select` with `default` case ensures event sends never block. If a client's event channel is full, the event is dropped for that client.

### 4. Leaving a Room

```go theme={null}
func (m *Manager) LeaveRoom(roomID, clientID string) bool {
    m.mu.Lock()
    defer m.mu.Unlock()

    room, exists := m.rooms[roomID]
    if !exists {
        return false
    }

    room.RemoveClient(clientID)

    // Clean up if room is now empty
    if room.ClientCount() == 0 {
        if room.Terminal != nil {
            room.Terminal.Close()
            room.Terminal = nil
        }
        // Delete workspace directory
        if room.WorkspaceDir != "" {
            os.RemoveAll(room.WorkspaceDir)
        }
        // Cleanup external resources
        if m.workerURL != "" {
            go m.cleanupRoomResources(roomID)
        }
        delete(m.rooms, roomID)
        return true // Room destroyed
    }
    return false // Room still active
}
```

**Cleanup Actions:**

1. Remove client from room's connection list
2. If last client:
   * Close terminal and kill shell process
   * Delete workspace directory from filesystem
   * Send DELETE request to worker (if configured) to clean up sandbox and AI state
   * Remove room from manager's map

### 5. Resource Cleanup

External resources are cleaned up asynchronously:

```go theme={null}
func (m *Manager) cleanupRoomResources(roomID string) {
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    
    url := fmt.Sprintf("%s/api/rooms/%s", m.workerURL, roomID)
    req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil)
    if err != nil {
        m.logger.Warn("failed to create cleanup request", "roomID", roomID, "error", err)
        return
    }
    
    httpClient := &http.Client{Timeout: 10 * time.Second}
    resp, err := httpClient.Do(req)
    if err != nil {
        m.logger.Warn("failed to cleanup room resources", "roomID", roomID, "error", err)
        return
    }
    defer resp.Body.Close()
    
    m.logger.Info("cleaned up room resources", "roomID", roomID)
}
```

This runs in a goroutine so it doesn't block the client's disconnect.

## Event Broadcasting

### Room Events

```go theme={null}
type RoomEvent struct {
    Type     string
    Username string
    Data     string
}
```

**Event Types:**

* `join`: User joined the room
* `leave`: User left the room
* `typing`: User is typing in terminal (debounced to 500ms)
* `ai_sync`: AI chat messages updated (triggers viewport refresh)

### Broadcast Implementation

```go theme={null}
func (r *Room) BroadcastEvent(event RoomEvent, excludeClientID string) {
    r.mu.RLock()
    defer r.mu.RUnlock()

    for _, c := range r.Connections {
        if c.ID != excludeClientID && c.Events != nil {
            select {
            case c.Events <- event:
            default:
            }
        }
    }
}
```

The `excludeClientID` parameter prevents echoing events back to the sender.

## Workspace Isolation

Each room's workspace provides:

### Directory Structure

```
/app/workspaces/
├── cosmic-phoenix/     (room 1)
│   ├── [template files]
│   └── [user files]
├── my-project/         (room 2)
│   └── ...
└── brave-dragon/       (room 3)
    └── ...
```

### Template Files

If `/app/workspace-template/` exists, it's copied to each workspace:

```bash theme={null}
# Production setup might include:
workspace-template/
├── .bashrc
├── .vimrc
├── README.md
└── tools/
    └── helper-scripts
```

### Terminal Working Directory

When starting the terminal:

```go theme={null}
workDir := "/app"
if m.currentRoom != nil && m.currentRoom.WorkspaceDir != "" {
    workDir = m.currentRoom.WorkspaceDir
}

m.terminal = terminal.New(terminalW, termH, workDir)
```

The shell process executes with `cmd.Dir = workDir`, providing filesystem isolation.

## AI Message Synchronization

Rooms store AI conversation history:

```go theme={null}
type AIMessage struct {
    Role   string `json:"role"`
    UserID string `json:"user_id"`
    Text   string `json:"text"`
    Ts     int64  `json:"ts"`
}
```

### Thread-Safe Access

```go theme={null}
func (r *Room) SetAIMessages(msgs []AIMessage) {
    r.mu.Lock()
    defer r.mu.Unlock()
    r.AIMessages = msgs
}

func (r *Room) GetAIMessages() []AIMessage {
    r.mu.RLock()
    defer r.mu.RUnlock()
    result := make([]AIMessage, len(r.AIMessages))
    copy(result, r.AIMessages)
    return result
}
```

### Sync Flow

1. Client sends AI message via `Ctrl+G`
2. Worker returns updated message history
3. Client calls `currentRoom.SetAIMessages(msgs)`
4. Client broadcasts `ai_sync` event
5. Other clients receive event and call `GetAIMessages()` to refresh their viewport

This ensures all clients see the same AI conversation in real-time.

## Concurrency Patterns

### Read-Heavy Optimization

`sync.RWMutex` is used because:

* **Reads** (GetRoom, RoomCount) are frequent (every client action)
* **Writes** (CreateRoom, LeaveRoom) are infrequent (only on join/leave)

Multiple goroutines can hold read locks simultaneously, improving performance.

### Channel-Based Events

Each client has a buffered channel (`chan RoomEvent, 10`) that:

* Decouples event producers (broadcast) from consumers (client UI loop)
* Prevents blocking if client is slow to process
* Automatically closes when client disconnects

### Graceful Reconnection

If a client reconnects with the same ID:

```go theme={null}
for i, c := range r.Connections {
    if c.ID == client.ID {
        if c.Events != nil {
            close(c.Events)
        }
        r.Connections = remove(r.Connections, i)
        break
    }
}
```

The old channel is closed and the client is re-added with a fresh channel.
